查看原文
其他

云原生 etcd 系列|用“租约”给 key 加一个期限!

奇伢 奇伢云存储 2023-04-23
坚持思考,就会很酷



什么是租约 ?


在 redis 中有一个 ttl 的功能。ttl 是 time to live 的缩写。在 redis 里我们可以设置 key 的 ttl ,从而指定这个 key 存活的时间,过期就会自动销毁。

在 etcd 也有一个类似的机制:租约( Lease )机制。从效果上来讲,租约机制也能做到类似的过期自动删除 key 的功能。但是两者细节大有不同。

租约( Lease )是什么?

简单讲就是一个具有一个时间期限的“对象”

划重点:时间期限。

举个不准确的栗子:

有一个大公司(代表一个中心权威组织,比如 etcd )有个粗活,并且工作特殊明确只需要一个程序猿( worker )。

那一般怎么操作呢?

有个程序猿 A 想做这个事,来找公司申请,于是公司给了他一个 3 天期限的租约( Lease ),并承诺该权限 3 天内不会再给这个权限给别人,但是 3 天之后,公司就可以另寻他猿了( 注意:如果猿 A 在 3 天内续约了,A 就可以延续他的权限了,那就是另外一回事了 )。

这样的话,就能保证始终只有一个猿有合法权限做这件事。 如果猿 A 三天后不抗压、失联了,那 3 天之后公司也能安全的(没有违反承诺)再找一只猿。

划重点:租约就是一个带时间期限的承诺。


怎么使用租约?


和 redis 不同,etcd 中把这个时间相关的概念抽离出来,命名为 Lease 对象。所以,要使用租约则先要创建这么一个 Lease 对象。然后把 key 绑定到这个 Lease 上,就相当于设置了这个 key 的生命周期。

细节来了,key 和 Lease 是怎么对应的?

划重点:key 和 Lease 是多对一的关系。一个 key 最多只能挂绑定一个 Lease ,但是一个 Lease 上能挂多个 key 。 这种设计提高 etcd 整体的性能。Lease 刷新一次就对应了一批的 key ,否则每一个 key 都独立刷新 ttl 的话,开销可不小呢。

举个 etcd 实际的栗子,怎么设置一个 60 秒有效的 key ?如下:

先创建一个 Lease 对象:

root@ubuntu:~/# etcdctl lease grant 60

lease 694d7d17eaab280f granted with TTL(60s)

再把一个 key 绑定到这个 Lease :

root@ubuntu:~/# etcdctl put hello world --lease=694d7d17eaab280f

OK

这样 hello/world 这对 key/value 就创建好了,并且 60 秒后将被自动删除。


租约机制的适用场景 ?


有些童鞋可能会好奇,租约一般用来做什么呢?

就本质上来讲,租约就是一个具有生命周期的对象。怎么使用它?这依赖于用户的想象力。

曾经我在分布式的分享章节里提到过,lease 是分布式的基石技术之一,lease 就是一个有时间限定的权限(承诺)。分布式的冗余节点都可以来申请一段时间的权限(有了这个权限就可以做某件事情),租约过期之后就可以回收,租约没过期之前就维持承诺。这个租约的管理一般放在一个中心化的节点(或者集群中),比如 etcd 集群。

为什么申请的权限一定要附上时间期限呢?

因为在分布式的恶劣环境下,谁都有可能挂。挂了的话,冗余节点要能顶上来,这个权限要能安全移交。租约没过期之前,权限的移交都是不安全的。租约过期之后,权限就能安全移交。所以,租约常常用在恶劣的分布式系统中做可靠的授权管理

还一个 etcd 最常见的场景是当作注册中心来用,worker 节点注册到 etcd 集群。每个都申请了租约,并且定期的会续约( keep-alive 保活),一旦长久失效,那么就可以剔除。这样起到一个节点的管理之用。


etcd 的租约原理


下面从 etcd 内部的实现原理出发,来看租约机制的核心知识点。在 etcd 中,由一个叫做 lessor 的对象来管理租约,并且关于续租等等操作都必须要是 leader 才能操作。


 1   租约的创建


租约的创建必须走 raft 状态机,把 Lease 创建这个消息在集群中达到一致,达到一致之后,每个节点就可以构建 Lease 结构体,并且持久化这个结构体到 boltdb 中,存储在一个叫做 "lease" 的 bucket 中

func (le *lessor) Grant(id LeaseID, ttl int64) (*Lease, error) {
    // 构造一个 Lease 结构体
    l := &Lease{
        ID:      id,
        ttl:     ttl,
        // ..
    }
    // 设置 expire time( primary 可做)
    l.refresh(0)
    // Lease 在内存的 map 里也放一份,好索引呀
    le.leaseMap[id] = l
    // 持久化到 boltdb 里去
    l.persistTo(le.b)
    // 投递到一个带小堆的队列中,这个关联超时机制(primary 可做)
    le.leaseExpiredNotifier.RegisterOrUpdate(item)
    // 投递到 checkpoint 的队列中,这个关联 checkpoint 机制(primary 可做)
    le.scheduleCheckpointIfNeeded(l)
}

租约创建很简单,最关键的是先要走 raft 机制,然后走上面的 grant 流程,传入一个 LeaseID,一个 ttl ,持久化到 boltdb 并修改内存结构,主要步骤:

  1. 构建一个 Lease 结构体;
  2. 修改 leaseMap,id => lease ;
  3. 持久化,把 Lease 这个结构体写到磁盘( boltdb );
    1. 对应写到“lease” 这个桶里;
  4. 设置 Lease.expiry ,这里是设置为 now + ttl 的时间,是未来超时的那个时刻;
  5. 构建一个 LeaseWithTime 的结构体,加入到 heap 里面去管理;
    1. 加到 leaseExpiredNotifier 里面,关联超时机制;
    2. 加到 leaseCheckpointHeap 里面,关联 checkpoint 机制;

划重点:Lease 的创建是要持久化的,并且是先走 raft 的状态机在 etcd 集群达到一致后,才持久化到 boltdb 中。




 2   租约的绑定


key 是怎么绑定到 Lease 的呢?这是一个非常关键的问题。

时机肯定在 key/value 上传的时候,也就是 put 的时候,位于 storeTxnWrite.put 方法之中:

// etcd/mvcc/kvstore_txn.go
func (tw *storeTxnWrite) put(key, value []byte, leaseID lease.LeaseID) {
    // ...
    // 存储到 bolt db 文件
    tw.tx.UnsafeSeqPut(keyBucketName, ibytes, d)
    // ...
    // 如果 leaseID 有效,那么说明要绑定 Lease 了
    if leaseID != lease.NoLease {
        // LeaseID 和 key 关联起来
        err = tw.s.le.Attach(leaseID, []lease.LeaseItem{{Key: string(key)}})
    }
}

划重点:数据持久化到 boltdb 之后,再去关联对应的 Lease 结构体。 那关联是什么操作呢?很简单,就是把这个 key 加到 Lease 内部的 map 中:

// etcd/lease/lessor.go
func (le *lessor) Attach(id LeaseID, items []LeaseItem) error {
    for _, it := range items {
        // 把这个 key 放到 Lease 结构体里
        l.itemSet[it] = struct{}{}
        // 把这个 key 放到 lessor 的结构体里,这里作为一个平坦的 map
        le.itemMap[it] = id
    }
}

跟这个 Lease 关联的所有 key 都在 Lease.itemSet 这个 map 中。




 3   租约的过期


租约的过期和销毁是 etcd 内部的流程触发。租约的过期在创建的时候就关联上了,还记得创建的时候有一个加队列的代码吗?

// 投递到一个带小堆的队列中,这个关联超时机制(primary 可做)
le.leaseExpiredNotifier.RegisterOrUpdate(item)

这行代码把 Lease 加到一个内含最小堆的结构中。每次都看小堆顶即可(因为它生命剩余最小,最有可能超时),小堆顶的 Lease 超时了,那么就取出来,直到取到没超时的 Lease ,那么本轮结束。

// 取出一个超时的 Lease 结构,它上面可能有一批的 key 
func (le *lessor) expireExists() (l *Lease, ok bool, next bool) {
    // 取小堆顶
    item := le.leaseExpiredNotifier.Poll()
    now := time.Now()
    // 看是否超时
    if now.UnixNano() < item.time /* expiration time */ {
        return l, falsefalse
    }
}

这样每次都处理一批超时的 Lease 结构,走销毁流程,过期销毁主要做两件事:

  1. 销毁 Lease 本身;
  2. 销毁 Lease 关联的 key/value 键值对 ;

key 被销毁之后就相当于被自动删除了,用户就下载不到了。 销毁的流程在 lessor.Revoke :

func (le *lessor) Revoke(id LeaseID) error {
    // 遍历删除这个 Lease 关联的所有 key (从 boltdb 里删除)
    for _, key := range keys {
        txn.DeleteRange([]byte(key), nil)
    }
    // 销毁内存结构
    delete(le.leaseMap, l.ID)
    // 把这个 Lease 从 boltdb 的 lease 桶里删除
    le.b.BatchTx().UnsafeDelete(leaseBucketName, int64ToBytes(int64(l.ID)))
}

划重点:Lease 的销毁不仅是内存的,还有 boltdb 的 lease 桶里的都要清理。是设计到持久化的。




 4   租约的续租


续租需要由 leader 完成,但是 etcd 的续租并没有走 raft 在集群中达到一致性。它仅仅是在内存中修改过期时间。

func (le *lessor) Renew(id LeaseID) (int64, error) {
    // 必须要 leader 节点才能做这个事情
    if !le.isPrimary() {
        return -1, ErrNotPrimary
    }
    // ...
    // 重置超时时间
    l.refresh(0)
    // 更新小堆的里面对应的元素
    le.leaseExpiredNotifier.RegisterOrUpdate(item)
}

可以看到,在 leader 节点里对于续租做的事情很简单,就是刷新过期( expiry )时间,并且刷新最小堆的元素,这样它就相当于续命了,不会超时啦。

划重点:续租没啥持久化的。


 5   租约的 CheckPoint 机制


在上面我们看到, Lease 创建和销毁是涉及到持久化的,对于续租则存储是内存操作。那这里在集群异常的场景可能导致一个不准确的问题。

比如 Lease 是 300 秒,已经过去 100 秒,突然切主。那起来的时候 Lease 就不知道多少了?

对于这个 etcd 有一个 checkpoint 机制,这个机制本质上就是定期让 leader 看一眼剩余的 ttl 还有多少,然后同步给集群其他节点,以此为准。

所以,CheckPoint 要走 raft 状态机。


 6   租约加载恢复


在 storeTxnWrite.put 里面我们看到了 key 上传的时候会和 Lease 关联,其实还有一个时机:etcd 进程启动的时候。

func (s *store) restore() error {
   // 遍历 bucket 里面所有的 key ,把所有跟 Lease 关联的 key 放到 keyToLease
    
    // 遍历这些和有 Lease 关联的 key
    for key, lid := range keyToLease {
        // Lease ID 和 key 关联起来
        err := s.le.Attach(lid, []lease.LeaseItem{{Key: key}})
    }
}

做的事情很简单,就是在进程重启的时候,需要加载分析所有的 key ,把那些跟 Lease 关联的 key 捞出来,然后跟 Lease 关联起来。

小知识点:lessor 结构体要先于此创建出来。


总结


  1. 租约是一个有时间期限的承诺
  2. Lease 是一个独立的结构对象,它具有一段指定的生命期限;
  3. key 绑定到 Lease 上就能实现 ttl 自动删除的功能;
  4. key 和 Lease 是多对一的关系,这种设计能够提升 etcd 的内部性能,并且具有相当的灵活性;
  5. Lease 的创建( Grant )和销毁( Revoke )都要持久化( 到 boltdb ),这之前要走 raft 状态机( 涉及到 wal 的持久化 );
  6. Lease 结构体数据存储在 boltdb 中一个叫做 “lease” 的 bucket 中;
  7. Lease 的 CheckPoint 会走 raft 状态机,但是不会持久化到后端 boltdb
  8. Lease 机制要慎用( 明确自己场景 ),因为它并没有严格保证 ttl 的时间准确的增减



后记


租约妙用多多,童鞋你用过哪些呢?点赞、在看 是对奇伢最大的支持。

~完~


往期推荐



往期推荐



云原生 etcd 系列|存储引擎 boltdb 的设计奥秘?

云原生 etcd 系列|快照技术是什么?

分布式基石|最难 paxos 和最易 raft ?

云原生 etcd 系列|Leader 是怎么选举出来的?

云原生 etcd 系列|为什么值得学习?



坚持思考,方向比努力更重要。关注我:奇伢云存储。欢迎加我好友,技术交流。

欢迎加我好友,技术交流。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存